Skip to content

feat(spec)!: retire the scheduled cache-warmup strategy — the cron it selected left in this same major (ADR-0049) - #17638

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-17157-cache-warmup-scheduled-retire
Sep 13, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-17157-cache-warmup-scheduled-retire

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Part of #17157

CacheWarmup.strategy no longer accepts 'scheduled', and its .describe() stops promising (cron).

#16320 (PR #17146) deleted CacheWarmup.schedule — the cron key this enum member selected — and declined the member itself as "a value, not a position this ruling names". That is a statement about that ruling's scope, not a finding that the value was sound. After the deletion the member declared a warmup cadence with no key left to configure it and no engine that has ever run one, while its own .describe() still promised (cron). ADR-0049 enforce-or-remove.

The four prerequisite readings, re-measured on origin/main at 690f083f83 — one is FALSIFIED

# claim reading on main verdict
1 the enum + describe still stand at cache.zod.ts:199-200 they stand, at :179-180 — the card's numbers were taken on PR #17146's branch ✅ (line numbers moved)
2 CacheWarmupSchema has zero consumers outside its declaring file zero runtime consumers, 6 non-runtime files — see below
3 #16320 landed; CacheWarmup.schedule is gone from main gone; only the tombstone comment remains in the shape
4 the D3 ledger entry cache-warmup-schedule-retired exists and says what the card quotes no such entry exists — see below falsified

#4 in full. git grep -rn "cache-warmup-schedule-retired" on main returns exactly one hit: packages/spec/src/cron-typed-positions-retirement.test.ts:212, inside NEVER_REGISTERED_IDS — a list of ids that pin test asserts ABSENT. #16320 landed as a bare deletion (直接删, maintainer ruling 2026-09-10) with no RETIRED_KEYS_BY_MAJOR entry, no D2 conversion and no D3 semantic entry. The statements the card quotes are real, but they live in the tombstone comment in cache.zod.ts and in the ADR-0058 D7 ledger comment in packages/qa/dogfood — not in a D3 ledger entry. The card's argument survives intact; only its citation was wrong.

#2 with its lit control reproduced (the card's zeros were all taken beside one, so the control is re-measured too):

$ git grep -l -P '\bCacheWarmupSchema\b' -- . ':!packages/spec/src/system/cache.zod.ts' \
      ':!packages/spec/api-surface/**' ':!packages/spec/authorable-surface/**' | wc -l
6

…and all six are non-runtime: the generated reference page's import line, declaration-map/system.json, export-origins/system.json, the ADR-0058 D7 ledger comment in packages/qa/dogfood, the #16320 pin test, and this package's own system/cache.test.ts. Zero runtime readers.

$ git grep -l -P '\bConnectorSchema\b' -- . ':!…surfaces…' | grep -v connector.zod.ts | wc -l
46          # lit control — the sweep sees consumers when there are any

(The card measured 48 for the control on #17146's branch; 46 on main. It is emphatically lit either way.)

CacheWarmup is also bound to no metadata type and embedded in no stack collection: git grep -n 'DistributedCacheConfigSchema|CacheWarmupSchema' -- packages/spec/src/stack.zod.ts packages/spec/src/kernel/ exits 1. So there is no authored source and no stored row anywhere that has ever carried this value, and os migrate meta has nothing to list.

The retirement mechanism — established, not invented

The card is right that there is no value-level analogue of retiredKey(), and none is invented here. The repo's established mechanism for an enum-VALUE retirement is the enum's own error map, dispatched on issue.input — three precedents on this tree:

precedent site
object.managedBy: 'system' data/object.zod.ts (MANAGED_BY_SYSTEM_RETIRED)
HookBodyCapability 'crypto.hash' (#4391) data/hook-body.zod.ts (CRYPTO_HASH_RETIRED)
HotReloadConfig.stateStrategy 'disk' / 'distributed' D3 entry hot-reload-inert-state-strategies-retired

.claude/skills/spec-property-retirement §2 names it outright as the only route that applies when the def survives and only its value set shrinks. Only the value that used to be legal gets the "was removed" sentence; strategy: 'sheduled' keeps zod's own enum message.

Registration follows the same precedents: an ADR-0087 D3 semantic entry (cache-warmup-scheduled-strategy-retired), not a D2 conversion (no source to rewrite) and nothing in RETIRED_KEYS_BY_MAJOR (no authorable key changed). The prescription deliberately carries no os migrate meta sentence — promising a listing the tool cannot produce would be the very defect this card is about; the house pin (retired-key-migrate-sentence.test.ts) judges only prescriptions that name the command.

⭐ The instrument — the negative leg, both legs quoted

An enum-value narrowing is invisible to all four ratchets, and this branch confirms it by measurement, not by argument: after pnpm --filter @objectstack/spec build, git status --porcelain was emptyauthorable-surface/system.json still carries system/CacheWarmup:strategy byte-for-byte (it keys on the position, which is untouched), and api-surface/, json-schema.manifest/ and the D7 ledger likewise. A green CI run is therefore not evidence this retirement works. The pin tests in system/cache.test.ts are the only instrument, so both legs are shown.

Resolution path: cache.test.ts imports ./cache.zod — the source, not dist/ — so no build interposes between the mutation and the reading.

HEAD blob = 6a5ab591773b4d0df382c7a3479f9b8d1c1dada4

########## AFTER LEG (retirement in place, HEAD) ##########
AFTER EXIT=0
 Test Files  1 passed (1)
      Tests  36 passed (36)

########## BEFORE LEG (schema reverted to origin/main 690f083f83) ##########
  occurrences of the retired member in the enum line: 1     ** mutation proven ON DISK
  occurrences of the injected prescription const:    0
  blob now = 813cf7828c9f4584c42d41da43a5675915e79933       ** differs from the HEAD blob
BEFORE EXIT=1
 FAIL  |local| src/system/cache.test.ts > CacheWarmupSchema >
        refuses `strategy: 'scheduled'` and answers with the retirement prescription
 AssertionError: expected [Function] to throw an error
 Test Files  1 failed (1)
      Tests  1 failed | 35 passed (36)

########## RESTORE ##########
  blob restored = 6a5ab591773b4d0df382c7a3479f9b8d1c1dada4  ** identical to the HEAD blob
  git diff HEAD (must be empty):
  [end]

Before: accepted. After: refused. The mutation is proved on disk by blob hash and by a grep anchored on the exact text that moved (an editor's exit code is not evidence); the restore is proved by git diff HEAD being empty, not by an exit code; and the script carried trap … EXIT INT TERM throughout.

The two neighbouring assertions pass in both legs, deliberately — they are controls, not the instrument: a typo ('sheduled') must not be told it "was removed" (or the error map matches everything and the pin above proves nothing), and 'eager' / 'lazy' / the 'lazy' default must still parse.

Changeset level

minor, on @objectstack/spec. Removing a published enum member is breaking; under the launch-window rule it ships as minorcheck-changeset-no-major.mjs refuses major, and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition (an adr-0087: registered cache-warmup-scheduled-strategy-retired marker in the changeset body). The level is committed, so the gate — which reads via git show HEAD:PATH — sees it. node scripts/check-changeset-no-major.mjs --base origin/main and node scripts/check-adr-0087-registration.mjs --base origin/main both exit 0 on this branch.

Verification — all readings at c170853213, the final commit

run verdict
pnpm --filter @objectstack/spec build && … typecheck && … test (under the shared verify lock) VERDICT command-exit 0; 473 test files, 13428 tests passed
pnpm --filter @objectstack/spec check:generated 15/15 green. One was stale on the first pass — check:docs — regenerated with gen:docs (the reference page's enum cell); check:authorable-surface, check:api-surface, check:spec-changes, check:upgrade-guide were green without regeneration, which is the ratchet-invisibility result above
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0 — no narrowing claimed, the full scan ran
node scripts/pm/dispatch-gates.mjs --ran 108 derived, 108 run, 0 UNRUN

Of the 108, 7 returned PREREQUISITE NOT MET (exit 3, or exit 1 with an explicit "nothing was measured" verdict) and are recorded as NOT MEASURED, never as green: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. Every one refuses for the same reason — it reads built sibling packages that this worktree has not built — and none reads a file this diff touches. CI builds the farm and decides them.

One gate found a real defect in this branch and is fixed in c170853213: check:doc-authoring Rule 3 flagged #16320 inside the customer-facing prescription string. A refusal message is printed verbatim at an author who has no tracker to resolve #NNNN with, so the id moved to the adjacent // comment and the string keeps ADR-0049 as its durable reference. Gate now green.

Sibling repo

git grep 'CacheWarmup' ../objectui → no hits. The Console Pin Gate is not in play.

Not in scope

The larger family question — whether CacheWarmup / DistributedCacheConfig.warmup should exist at all — is the ADR-0049 family-level decision the card names and is deliberately untouched here. Part of #17157 rather than Fixes, since this closes the enum member and not that question.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH


Generated by Claude Code

`CacheWarmup.strategy` no longer accepts `'scheduled'`, and its `.describe()`
stops promising "(cron)".

#16320 deleted `CacheWarmup.schedule` — the cron key this enum member selected —
and declined the member itself as "a value, not a position this ruling names".
That is a statement about that ruling's scope, not a finding that the value was
sound: after the deletion the member declared a warmup cadence with no key left
to configure it and no engine that has ever run one. ADR-0049 enforce-or-remove.

An enum-VALUE narrowing is invisible to all four surface ratchets, so the
prescription hangs on the enum's own `error` map dispatched by `issue.input` —
the established route (`crypto.hash`, `managedBy: 'system'`,
`HotReloadConfig.stateStrategy`) — and the ADR-0087 D3 semantic entry
`cache-warmup-scheduled-strategy-retired` is the declaration. No D2 conversion
and no `RETIRED_KEYS_BY_MAJOR` entry: no authorable key changed, and `CacheWarmup`
is bound to no metadata type and embedded in no stack collection, so there is no
authored source and no stored row to rewrite.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…up enum

`gen:docs` — the only one of the 15 generated artifacts `check:generated`
proved stale. The four surface ratchets are byte-identical, as an enum-VALUE
narrowing predicts.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…up prescription

`check:doc-authoring` Rule 3: a refusal message is printed verbatim at an author
who has no tracker to resolve `#NNNN` with. The id moves to the adjacent `//`
comment; the string keeps ADR-0049 as its durable reference.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8f751cdc5652afb7dcd8e906d0f281234fc886c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8e4c4501d008fb9d665ce57cebb8775be4f1dbc7 — the merge of head c1708532137a8de0fdfb47b8500a3156dbeec48d into base 8f751cdc5652afb7dcd8e906d0f281234fc886c3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8e4c4501d008fb9d665ce57cebb8775be4f1dbc7 && git checkout 8e4c4501d008fb9d665ce57cebb8775be4f1dbc7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f751cdc5652afb7dcd8e906d0f281234fc886c3 c1708532137a8de0fdfb47b8500a3156dbeec48d && git checkout -B drift-repro 8f751cdc5652afb7dcd8e906d0f281234fc886c3 && git merge --no-ff c1708532137a8de0fdfb47b8500a3156dbeec48d

node scripts/docs-audit/affected-docs.mjs --json 8f751cdc5652afb7dcd8e906d0f281234fc886c3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-sales added a commit that referenced this pull request Sep 12, 2026
…et, as a new key

`check-widening-tells`' T1 tell fires on any added line shaped
`identifier: z.Something` on the contract source surface. Two live pairs
raised it against diffs that add no key at all:

- PR #17616 — `ctx: z.RefinementCtx`, the second parameter of an exported
  object-level refinement. That signature is the repo's prescribed one, so
  every diff adding a cross-field refusal raised a widening tell for the
  refusal itself.
- PR #17638 — `strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the
  same block removed with one member MORE. Exit 4 against a correct
  `Clause-②: no`, holding a reviewed retirement PR out of the queue.

The second was the net-delta budget being EARNED and refused at the spend:
"a line that DECLARES a closed set is never spent" was written about an
opener, which `memberTellKind` already answers `null` for, so the only lines
it caught were keys whose value opens a closed set. It is replaced by the
thing it protected — an inline set widened in place is visible on the T1 row
alone — so such a key spends only on three facts the block carries: a removed
line naming the same key, both member lists readable inline, and the added
list a subset of the removed one.

The parameter half reads which bracket is innermost over the line's own hunk;
a shape body is brace-delimited by construction, and every state the reader
cannot carry honestly answers "keep firing".

Measured over the 233 commits touching these surfaces (`e9efc403`): of 20,193
rows the previous reading raises, 23 decline and 20,170 stand — all 23 T1,
fifteen parameters and eight existing keys re-spelled around an identical
enum.

Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review September 13, 2026 08:06
@os-zhuang
os-zhuang enabled auto-merge September 13, 2026 08:06
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 146c291 Sep 13, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-17157-cache-warmup-scheduled-retire branch September 13, 2026 08:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…et, as a new key (objectstack-ai#17760)

Fixes objectstack-ai#17618

## What was wrong

`check-widening-tells`' **T1** tell reads an added line shaped
`identifier: z.Something` on the contract source surface as *"a new key
on a Zod object schema — the accept set gains a spelling an author may
now write"*. Two live pairs raised it against diffs that spell nothing
new. Both were reproduced from the PRs' own pushed bytes **before**
anything was written.

**Instance 1 — PR objectstack-ai#17616
(`packages/spec/src/ui/dashboard.zod.ts:470`).** The row fires on `ctx:
z.RefinementCtx,`, the second **parameter** of an exported object-level
refinement. `z.RefinementCtx` is a type; nothing constructs a shape
there, and the diff it appears on *refuses* metadata that parses today.
That signature is this repo's own prescribed one (the `objectstack-ai#16489`
convention — `checkListViewPageMount`, `checkPageSourceCompleteness`,
`checkGlobalFilterDateDefaultValue`), so **every** diff adding a
cross-field refusal raised a widening tell for the refusal itself.

**Instance 2 — PR objectstack-ai#17638
(`packages/spec/src/system/cache.zod.ts:197`).** The row fires on
`strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the same change
block removed as `strategy: z.enum(['eager', 'lazy',
'scheduled']).default('lazy')`. Same key, one member **fewer**. That
pair declares `Clause-②: no`, so it exited **4** and held a reviewed,
green retirement PR out of the queue, where the only sanctioned clear is
the false `yes` the file already refuses to ask an author for.

## The measurement that picked the fix

Instance 2 is **not** the objectstack-ai#16943 net-delta budget being too thin. The
budget was *earned* — the removed `strategy:` line is itself T1-shaped
and bought one T1 unit — and then refused at the **spend**, by
`!CLOSED_SET_OPENER.test(text)`: "a line that DECLARES a closed set is
never spent against the budget". That clause was written about an
**opener**, and an opener never reaches it: `memberTellKind` already
answers `null` for an opener-only line. So the only lines it ever caught
were **keys** whose value opens `z.enum(` / `z.union(` /
`z.discriminatedUnion(` / `z.literal(`. Measured on this tree with the
pre-change reader:

```
field: z.string()          -> field: z.string().optional()          0 row(s)   (declines)
kind:  z.enum(['a'])       -> kind:  z.enum(['a']).optional()       1 row      (fires)
```

The asymmetry was accidental.

## What changed (`scripts/pm/check-widening-tells.mjs`, one file)

**The spend.** What the blanket refusal was protecting is real and is
kept: an **inline** set has no per-member line for T2 to read, so a set
widened in place is visible on the T1 row and nowhere else. A
closed-set-valued key may now spend the budget only on three facts the
**block** carries — a removed line naming the **same key**, both member
lists readable on their own line, and the added list a **subset** of the
removed one. `z.enum(['a', 'b'])` to `z.enum(['a', 'b', 'c'])` still
fires; a list that opens on a later line is unreadable and still fires;
another key's removal pays nothing.

**The parameter.** The claim is deliberately smaller than the
depth-aware `z.object({ … })` reader T1's own comment refuses — the one
whose cheap version fails GREEN by truncating. A Zod shape body is
`{`-delimited **by construction**, so the question is never "which shape
is this line in" but "which bracket is innermost", read over the line's
**own hunk**, plus a declaration head left of that paren. Every state
the reader cannot carry honestly answers `null` — *keep the tell
firing*: a closer arriving on an empty stack (the hunk began inside
something it was never shown), a string literal that does not close on
its line, a declaration head it does not recognise (a method shorthand,
for one). Nothing it returns ever means "no longer inside a shape",
which is why it has no truncating failure mode. The same reading is
applied on the **old** side too, the way objectstack-ai#16822's fragment rule is: a
deleted parameter must not buy an added key the right to go unreported.

## Price of the change, measured

Over the **233 commits** touching these surfaces in this tree's history,
A/B'd row-for-row between the pre-change reader (`origin/main`
`e9efc403`) and this one:

| | rows |
| --- | --- |
| tell rows the previous reading raises | 20,193 |
| the new reading raises | 20,170 |
| **now decline** | **23** — all T1; no T2, T3 or T4 row moves |

Of the 23: **15 are parameters** (twelve `ctx: z.RefinementCtx` /
`z.core.$RefinementCtx`, three `input: z.input[typeof SomeSchema]`) and
**8 are existing keys re-spelled** to carry `.meta({ title })` or a
rewritten `.describe()` around an identical enum (e.g. `d2badf723`,
`f502898a4`). Not one is a key or a member its diff added. On the tree
itself, **16 of 8,974** T1-shaped lines under `packages/spec/src/**` sit
inside a parameter list, 10 of them annotated `z.RefinementCtx`.

The file's header prices this in the register it already uses, and
states both quiet directions rather than leaving them to be discovered:

1. A parameter **added to an already-exported** function is a signature
widening and now goes unreported here; nothing else in the file catches
it (T3's listing records that an export EXISTS, and
`api-surface-signatures.json` carries 27 `define*` helpers, none of them
one of these checks). What is not lost is the function itself — a newly
exported check adds its own T3 row, which is why objectstack-ai#17616 still reports
one.
2. A widening carried by the **chained methods** rather than the member
list (`.optional()` first among them) now declines on a
closed-set-valued key. Not a new class: objectstack-ai#16943 already declines it for
every key whose value is not a closed set; this removes the accidental
exception rather than adding one. In the measured population all eight
re-spellings are `.meta` / `.describe` rewrites and none adds
`.optional()`.

## Acceptance (grading comment 5642818369)

| item | before | after |
| --- | --- | --- |
| the parameter-list line reads no tell | T1 at `dashboard.zod.ts:470` |
no row |
| the in-shape re-spelling with a shrinking set reads no tell | T1 at
`cache.zod.ts:197` | no row |
| a genuinely new key on a shape STILL tells (control) |
`manifest.zod.ts:44` | `manifest.zod.ts:44` |
| the `objectstack-ai#16489` signature as a named fixture | — |
`FILE_REFINEMENT_SIGNATURE` in `--self-test` |
| `--pair 17638` with its `Clause-②: no` unchanged | **exit 4**, one T1
row | **exit 0**, no tell |

```
$ node scripts/pm/check-clause2-carriers.mjs --pair 17638
check-clause2-carriers: PR objectstack-ai#17638 / card objectstack-ai#17157 — the clause-② declaration is readable in the fixed
spelling and both carriers agree, and its diff carries no widening tell.   (exit 0)
```

⚠️ **One acceptance item could not be run as written, and the reason is
a fact about the board, not about the tree.** `--pair 17616` cannot form
a pair: PR objectstack-ai#17616 **merged at 2026-09-11T07:41Z**, and the carriers
script only judges an open PR — it exits **2** ("PR objectstack-ai#17616 is not
open"), both before and after this change, which is not a T1 reading in
either direction. Instance 1 is therefore reproduced and pinned where
the reading actually lives: `tellsInFile` over that PR's real pushed
patch (the bytes are the `--self-test` fixture), and the merged commit
`1f0b5659e` is one of the 23 declines in the history A/B above.

## Verification

- `node scripts/pm/check-widening-tells.mjs --self-test` — **269 cases
pass** (245 before; +24 in the new battery, registered in the roster so
the floor cannot be silenced by deleting it).
- `node scripts/pm/check-clause2-carriers.mjs --self-test` — **465 cases
pass** (it imports this file).
- Gate families derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` on the final commit and
reconciled with `--ran`; every derived command run, exit codes in the
report.
- The decline is bracketed on every side by controls that still fire —
an inline enum widened in place, a different key carrying a subset set,
a list that opens on a later line, a real key added after the parameter
list closes — because a reading that can only suppress is untestable in
the direction that matters.

## Gate record — run on the final commit `923aed38a`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives **32** families for the one changed
path; all 32 were run and all 32 exited **0**, then reconciled with
`--ran` (each line recorded as `command :: exit code`, captured before
any pipe):

```
Run reconciliation — 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED
  (a DERIVED zero — all 32 recorded an exit code and none of them is 3).
```

Beyond the derived families, the two this diff owes by kind: `pnpm
check:pm-widening-tells` (this file's own `--self-test`, which is what
`lint.yml` runs) and `node scripts/pm/check-clause2-carriers.mjs
--self-test` (the importing sibling) — both exit 0 and both are inside
the 32. `git grep` finds no `*.test.ts` naming this script; its suite IS
the self-test.

Lint is delivered as a **proven narrowing**, not skipped: (i) the
population is read from eslint's own config — the base entry's `files`
glob is the whole tree (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`), which
covers this path; (ii) `npx eslint --no-inline-config --format json
scripts/pm/check-widening-tells.mjs` reports **1 file, 0 errors, 0
warnings** (count read from the JSON, exit 0); (iii) invariance — this
repo runs one `eslint.config.mjs` which **never enables type-aware
linting for any file** (no `parserOptions.project`, no typed
`@typescript-eslint` rules; the config says so at
`eslint.config.mjs:326`), so a one-file diff cannot move the verdict on
any file it does not touch. The whole-repo `eslint . --no-inline-config`
remains CI's run.

## The one judgment call, on the four axes

The card offered two shapes and this PR takes the smaller one.
**实际业务需求**: the population is measured, not speculative — 233 commits,
23 rows, and one PR currently blocked by the instrument. **项目长远合理性**:
the reading adds one bracket fact a hunk genuinely carries and refuses
the depth-aware shape reader whose cheap version truncates; `null` means
"keep firing" everywhere, so it cannot degrade silently (Route & surface
ownership §3). **防 AI 写错**: the defect pushed a seat *away* from
tightening a contract — it reported the narrowing direction as the
widening one — which is the inverse of what clause ② exists to catch;
correcting it makes the strict direction the cheap one. **创业阶段不扩散**: one
file, no new flag, no new label, no new claim-line syntax, and no exit
code moves.

## Acceptance notes

- No out-of-scope edits: `check-clause2-carriers.mjs`,
`packages/spec/**` and `.claude/**` are untouched; PR objectstack-ai#17638 is
read-only evidence.
- Changeset: `scripts/pm/**` publishes nothing from any package's
`files[]` — this is the `skip-changeset` case the `Check Changeset`
gate's own text calls textbook ("this PR edits a CI-internal script").
The label is applied on the PR.

Authored by the skills-lane dev seat, session
`session_01MCLBsUgfykL74aU716rzVK`, on claim comment 5642827410.


---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: os-sales <sales@objectstack.ai>
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… its ADR-0087 entry (objectstack-ai#17761)

Part of objectstack-ai#17493

Items ① and ② of the card. ⛔ Item ③ is a **ruling, not a refactor** — it
is measured and reported here, and deliberately **not implemented**; the
card stays open on it, and this PR therefore carries no closing keyword.

Nothing in this diff changes behaviour. The refusal already shipped
(objectstack-ai#17322 at `registerFlow`, objectstack-ai#17495 at `objectstack validate`); what
shipped with it was the notification, and this is that half.

- **Clause-②: no** — this PR puts no new key on any published payload.

---

## ① The on-site ruling that had become false

`packages/spec/src/automation/flow-node-expression-paths.ts` — the
docblock of `structuralConditionRefusal`.

It admitted a whitespace-only string on this ground, verbatim as it
stood:

> every **string**, including a whitespace-only one. What a non-empty
string *says* stays `validateExpression('predicate', …)`'s verdict, and
**a whitespace-only condition meaning `false` is consistent on both
sides and is ruled correct, not a defect.**

objectstack-ai#15807 removed that ground (`FlowEdgeSchema.condition` composes
`EvaluatedExpressionInputSchema`), objectstack-ai#17322 ruled on the disagreement that
left, and objectstack-ai#17495 finished it at the third door. ⛔ Not deleted — it
records a real decision, so the edit says **what changed it**:

- The admission itself is **unchanged and still correct**. This function
answers the SHAPE question; a string is a well-shaped structural
condition. The blank is refused **beside** it, by the imported
evaluated-slot rule, answering `EVALUATED_EXPRESSION_SOURCE_REQUIRED`
and not `STRUCTURAL_CONDITION_SHAPE_REFUSAL`.
- A new section records **why item ③'s question is open**, without
answering it.
- The same stale ground sat three lines from the symbol, in
`flow-node-expression-paths.test.ts`'s own case comment ("not authored
on both sides and stays so"). Same defect class, same file face,
mechanical: the comment is corrected, both assertions untouched. ⭐
Declared as a bounded on-site fix beyond the dispatched file face.

## ② The ADR-0087 entry named only the edge slot


`packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts`
— `surface` and `acceptanceCriteria`.

Both named only `edges[].condition`, so a consumer replaying the chain
was told to sweep the edge key alone and a deployment carrying a blank
`config.condition` on a node was **never told to look**. Both now name
both structural slots, plus the node key's own locator phrase (the one
the structural pass builds, e.g. `node 'gate' (start) condition`) beside
the edge's `flows.N.edges.N.condition`, and the sweep carries the
warning that deleting a `condition` from a `start` node **opens the
trigger gate** rather than preserving it.

- `id`, `replacement` and `reason` are untouched, per the card.
- No backticks in `surface` — measured, 0 (`build-upgrade-guide.ts`
renders it inside a code span).
- **No new entry.** This is one decision reaching its second slot, which
is what both follow-up changesets
(`blank-node-condition-refused-at-registration.md`,
`validate-refuses-blank-structural-condition.md`) already declared by
taking the `not-required (already-registered …)` disposition against
this very entry.
- `packages/spec/src/migrations/registry.ts` is **regenerated by the
repo's own generator**, `pnpm --filter @objectstack/spec
gen:migration-registry` — ⛔ never hand-edited.
`check:migration-registry` is green.

## ⛔ ③ Measured, reported, NOT implemented

`registerFlow` still **ACCEPTS** a whitespace-only string at both
sibling ledger `predicate` slots, on this head, against controls that
prove the probe reaches them:

| slot | valid CEL (positive control) | envelope with a blank source
(refusal control) | `' '` | `''` |
|:--|:--|:--|:--|:--|
| `config.conditions[].expression` | ACCEPTED | REFUSED —
`PREDICATE_SLOT_STRING_REFUSAL` at `config.conditions[0].expression` |
**ACCEPTED** | **ACCEPTED** |
| `screen.fields[].visibleWhen` | ACCEPTED | REFUSED —
`PREDICATE_SLOT_STRING_REFUSAL` at `config.fields[0].visibleWhen` |
**ACCEPTED** | **ACCEPTED** |
| `config.condition` (lit control — the slot objectstack-ai#17322 DID narrow) |
ACCEPTED | REFUSED | **REFUSED** | **REFUSED** |

`evaluateCondition(' ')` and `evaluateCondition('')` both answer `false`
— the same silent dead branch. The current behaviour is pinned as
correct by objectstack-ai#15572 at
`packages/services/service-automation/src/decision-predicate-envelope.test.ts`
(the `it` block at `:110`–`:114` on this head; the card cited
`:113`–`:117` from `aefbb07b2`, a 3-line drift, the pin itself intact).

⛔ Narrowing those slots re-judges that pin and moves a published
accept-set: **a ruling, not a refactor**. `packages/services/**` is
untouched by this diff — 0 files.

## 验收备注

- `packages/lint`'s `predicateSlotRefusal`-side prose and
`flow-node-expression-paths.test.ts:189` also rest on "consistent on
both sides" — but for the **ledger predicate slots** that statement is
still **true** (resolver skips the blank, evaluator answers `false`, and
neither has been rebound). Noted, not filed: nothing to correct until
item ③ is ruled on.
- `packages/spec/src/migrations/registry.ts` is a hot shared file: PR
objectstack-ai#17638 and PR objectstack-ai#17635 both regenerate it from different entry files. No
conflict at this head; `origin/main` merged at `5a77c75b29` and the
registry regenerated and re-verified after.

## Verification

Every run below is on the final head `5a77c75b29` (`origin/main` merged
at that commit), with the gate's own verdict line, never a bare exit
status read through a pipe.

- `pnpm --filter @objectstack/spec build && check:generated && typecheck
&& test` — VERDICT `command-exit 0`; **473 test files / 13436 tests
passed**.
- The one test-layer file this diff touches, re-run alone: `pnpm
--filter @objectstack/spec exec vitest run --maxWorkers=2
src/automation/flow-node-expression-paths.test.ts` — 1 file / **26 tests
passed**.
- `node scripts/pm/dispatch-gates.mjs --commands` derived **84** gate
families for this change set; **84 run, 82 green, 0 unrun** — reconciled
with `--ran`, exit codes captured before any pipe. The two non-green
exited **3 = PREREQUISITE NOT MET, i.e. NOT MEASURED and not a
finding**: `check:dual-build-cjs-loads` and `check:type-check-debt` both
refuse without a repo-wide build closure, and both are declared to CI,
which builds one.
- Item ②'s own gate, `check:adr-0087-registration` — exit 0, verdict:
"this PR adds no declared-breaking changeset (1 non-breaking
changeset(s) seen)". `check:migration-registry` exit 0, verdict:
"src/migrations/registry.ts is current (202 semantic, 167 retired-key,
178 retired-def)". `check:spec-changes` and `check:upgrade-guide` both
report their artifact up to date; `check:docs` reports 222 generated
files in sync.
- `eslint . --no-inline-config --format json` over the **whole repo**,
eslint's own population read from its JSON output: **6638 files, 0
errors, 0 warnings**. No narrowing claimed and none needed.
- `check:nul-bytes` exit 0 (8441 text files), plus a control-character
self-scan over every changed file: 0 hits.

Changeset: `.changeset/17493-node-door-refusal-residues.md`,
`@objectstack/spec: patch`, card-scoped filename in the tree's
prevailing spelling.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… the permission-set route (objectstack-ai#17835)

Fixes objectstack-ai#16929

Executes **Ruling A** — director seat, decision batch objectstack-ai#121 item 2,
comment `5644017943` (2026-09-12), carrying the maintainer's 「同意」.
Nothing here re-opens a question that ruling settled; alternatives B / C
/ E are not revisited.

**Clause-②: no** — this is a removal / narrowing. Nothing is widened, so
no `needs:contract-review`.

PR objectstack-ai#17401's landed half (the two `guidance` prescriptions stopping
naming the key) **stands and is not redone**: both prescriptions on
`origin/main` already omit it, and this branch leaves their text alone.

---

## The six ruled items, one by one

| # | ruled | done |
| --- | --- | --- |
| 1 | `page.zod.ts`: `assignedProfiles` removed; `profiles` /
`assignedTo` become refusals naming the permission-set route; the two
guidance strings rewritten | ✅ — with one **route correction**, below |
| 2 | `page.form.ts` helpText and its four locale bundles removed | ✅ |
| 3 | the three false records corrected in the same PR | ✅ — with one
**relocation**, below |
| 4 | `major` changeset + an ADR-0087 semantic migration entry; key
stripped on `migrate meta --stored` with a structured TODO | ✅
substance; ⚠️ the `major` **grade** is refused by a standing repo-wide
gate — see *One ruled item the tree refuses* |
| 5 | PR objectstack-ai#17401's landed half stands | ✅ untouched |
| 6 | `Clause-②: no` | ✅ declared above in the fixed spelling |

### Item 1 — route correction: a `retiredKey()` tombstone, not a bare
shape deletion

The retirement playbook offers two routes and keys the choice on whether
the schema is strict: `retiredKey()` for a non-strict schema,
delete-plus-`guidance` for a strict one. `PageSchema` is a
`strictObject`, so the first attempt took the strict route — and the
build refused it:

```
❌ 1 authorable key(s) disappeared from the contract:
     - ui/Page:assignedProfiles
```

`scripts/build-schemas.ts` check (a) is fatal for **any** key that
leaves an emitting def, strictness notwithstanding, and check (c) then
ratchets a baseline deletion against the merge base on one of three
proofs — aged-out tombstone, def unreachable from the metadata-type
roots, or whole def gone. `ui/Page` is reachable from the `page` root
and keeps emitting, so none holds. The route the tree actually permits
here is the tombstone, which is also what the sibling `view.pageName`
retirement took two days ago.

This is not a softening of the ruling. The key is **unwritable**: `tsc`
types it `never`, and a value reaching a parse raises the prescription.
It simply stays in the *walked shape*, which is why its liveness row
stays (as `dead`) and why the authorable-surface baseline marks it
`[RETIRED]` instead of losing the line.

### Item 3 — one of the three false records is not where the ruling says
it is

Every `path:line` was re-derived by sentence rather than trusted. Two of
the three resolved as written; the second did not.

| ruled location | what is actually there |
| --- | --- |
| `packages/spec/liveness/page.json` — `live` citing a non-existent
objectui bridge | ✅ **found**, corrected |
| `packages/spec/liveness/view.json:125` — the "page audience gate"
justification | ❌ **not there any more.** `git grep` over that file
finds zero hits for `audience`, `assignedProfiles` or `page audience`
(lit control: `pageName` reads 3 lines in the same file; dark control
0). The objectstack-ai#17063 `pageName` retirement rewrote that row on 2026-09-10 and
the justification left with it. The same assertion is live at
**`packages/spec/src/api/protocol.zod.ts`** (`SearchAllPageHitSchema`'s
TSDoc) — that is the one corrected here |
| `packages/metadata-protocol/src/protocol.ts` — "enforced at page
render" | ✅ **found**, corrected |

So the count is still three, and all three assertions are gone; one of
them lives at a different address than the ruling recorded.

**Before → after, and what makes the new text true.**

1. `packages/spec/liveness/page.json` — was `status: "live"`, note:
*"profile-scoped page audience; objectui bridges it
(react/src/spec-bridge/bridges/page.ts) to
PageLayout.assignedProfiles."* Now `status: "dead"` with a `verifiedAt`
and a note recording that the cited path does not exist in objectui (nor
does any `spec-bridge` directory), while two sibling objectui citations
in the same file resolve. True because the key is now a tombstone and
the ledger's own route table says a tombstoned key keeps its row with a
`dead` verdict.
2. `packages/spec/src/api/protocol.zod.ts` — was *"where the page's own
audience gate (`assignedProfiles`) applies unchanged"*. Now states that
a page has no audience gate of its own, that the key which read as one
was removed precisely because nothing enforced it, and that what
protects a page is the permission sets on the data it shows. True
because the key no longer exists and never had a reader.
3. `packages/metadata-protocol/src/protocol.ts` — was *"is enforced
where it is enforced now, at page render"*. Now states the opposite and
keeps the delegation posture the sweep rests on, which never depended on
the key. True by the cross-repo measurement the card and triage both
took.

⚠️ `packages/metadata-protocol/**` is `domain:engine`'s lane. It is here
**only** because ruling item 3 puts all three records in one PR, and
exactly one sentence is touched.

---

## Measurement

### The removal is real, and it reaches the built artifact

Probed against the **built** `packages/spec/dist/ui/index.mjs` before
and after, same script both times.

**Before** (`origin/main` content, built):

```
A assignedProfiles :: ACCEPTED :: keys=assignedProfiles,isDefault,kind,label,name,regions,template,type :: value=["admin"]
B profiles(alias)  :: REFUSED  :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`. Did you mean `profiles` → `assignedProfiles`? …
C assignedTo       :: REFUSED  :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `assignedTo`. Did you mean `assignedTo` → `assignedProfiles`? …
D LIT-CONTROL icon :: ACCEPTED
E DARK-CONTROL     :: REFUSED  :: code=unrecognized_keys
```

**After** (this branch, rebuilt):

```
A assignedProfiles :: REFUSED :: code=invalid_type :: path=["assignedProfiles"] ::
   `page.assignedProfiles` was removed in @objectstack/spec 18 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was
   named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door
   ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the
   key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and
   bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime
   actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.

B profiles(alias)  :: REFUSED :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`.
   • `profiles` / `assignedTo` are not page keys (ADR-0090 D2: no Profile concept). Page audience is the permission
     set's — gate the DATA the page shows with the object's permission sets, and bind those sets to people through
     positions (`sys_position_permission_set`), never on the page itself. …

C assignedTo       :: REFUSED :: same sentence
D LIT-CONTROL icon :: ACCEPTED  (unchanged)
E DARK-CONTROL     :: REFUSED :: code=unrecognized_keys  (unchanged — the generic report, no rename offered)
```

That is the actual refusal text, not a claim that one exists. Note the
refusal **moved channel**: `unrecognized_keys` at the page →
`invalid_type` located at `["assignedProfiles"]`, which is what a
`z.never()` tombstone produces.

### The alias refusals point somewhere true — read, not inherited

A previous round on this card asserted that an alias table runs only
from the `unrecognized_keys` path. I re-read the source rather than
inherit it. `packages/spec/src/shared/strict-object.ts`'s own docblock
puts it in terms — *"`aliases` … is consulted BEFORE the distance
fallback"* inside `strictUnknownKeyError` — and
`shared/alias-integrity.test.ts` states the mechanism as the premise of
the gate it implements: *"an alias only ever runs from the
`unrecognized_keys` path, so a key the shape declares can never reach
it."*

**The before-probe is the direct evidence**: rows B and C above are
`REFUSED` with `code=unrecognized_keys` **before** any change. So
`profiles` and `assignedTo` were never in the accept set, the alias only
decorated the rejection, and **deleting or repointing those entries
narrows nothing** — same code, same path, different text. My own reading
agrees with the earlier round's.

A second consequence made the entries impossible to keep:
`alias-integrity.test.ts` asserts that an alias's *target* is a key the
shape accepts. Once the key is a tombstone, `profiles:
'assignedProfiles'` would point at a key the schema cannot accept — the
ledger's finding-7 shape. They had to become guidance.

### The migration entry actually fires

Driven over a stored page carrying the key, against the built artifact:

```
=== 1. `migrate meta` chain replay (authored source, 17 -> 18) ===
stack after :: {"pages":[{"name":"deal_desk","label":"Deal Desk","isDefault":false}]}
key still present? false
applied :: [{"toMajor":18,"conversionId":"page-assigned-profiles-removed","surface":"page.assignedProfiles",
             "from":"assignedProfiles","to":"(removed)","path":"pages[0].assignedProfiles"}]
structured TODO present? true
  TODO.toMajor = 18
  TODO.surface = `page.assignedProfiles` — the per-page audience list (REMOVED)

=== 2. stored-row rehydration (the `migrate meta --stored` seam) ===
stored row after :: {"name":"deal_desk","label":"Deal Desk","isDefault":false}
key still present? false

=== 3. CONTROLS ===
LIT : a page WITHOUT the key is untouched :: {"name":"team_home","label":"Team Home","isDefault":true}
DARK: a fabricated key is NOT stripped     :: {"name":"p","label":"P","zzqqNeverDeclared":1}
```

The key goes on both seams and the structured TODO appears. The strip is
deliberately paired with a D3 semantic entry rather than left to read as
"handled": which permission set a given profile name corresponds to is a
judgement no walker can derive.

### Ablation — the three new pins, RED before and GREEN after

One mutation leg restores both halves of the fix (the live key, and the
two alias entries in place of the guidance ones).

```
HEAD blob for packages/spec/src/ui/page.zod.ts = 388c9ee

leg 0  GREEN baseline                     Tests  83 passed (83)

leg 1  on-disk proof of the mutation
         tombstone line          1 -> 0
         live-key line           0 -> 1
         guidance entries        2 -> 0
         blob hash  388c9ee… -> 966b7ef6…        (changed, so the write landed)
       RED
         × refuses `assignedProfiles` at the tombstone and prescribes the permission-set route
         × answers `profiles:` with the permission-set route, not the retired key
         × answers `assignedTo:` with the same sentence as `profiles:`
         Tests  3 failed | 80 passed (83)        ← exactly the three new pins, nothing else

leg 2  restore (git checkout HEAD -- <abs path>; trap on EXIT INT TERM)
         restored blob hash = 388c9ee  == HEAD blob
         git diff HEAD      :: ''   (empty)
         git status --porcelain :: ''   (empty — index AND tree)
       GREEN                                Tests  83 passed (83)
```

Restore is proven by the blob hash against the HEAD blob and by an empty
`git diff HEAD` + empty `git status --porcelain`, never by an exit code.
No rebuild leg is needed and none is claimed: `page.test.ts` imports
`./page.zod` — a relative source path inside the same package — so this
ablation never resolves through `dist`.

⚠️ One reading in that run is **NOT MEASURED**: the `alias line`
occurrence count printed empty because the `grep -c` was mis-quoted
inside a double-quoted `$(...)`. The mutation is nonetheless established
by the two counts that did fire and by the blob-hash change; a clean
re-grep on the shipped file reads **0** alias lines (lit control
`aliases: {` = 6, dark control = 0).

### Changeset — it reaches a published `dist`, with both controls

Built **first**, then measured (`npm pack --dry-run --json` before a
build reads `dist` as empty — that trap is avoided).

- **Positive control** — the new prescription is in **18** packed
`dist/` files and in `dist/index.d.ts` (3 hits), so a consumer's `tsc`
and runtime both see it. `src/ui/page.zod.ts` is itself in the packed
list.
- **Negative control** — `src/ui/page.test.ts` and
`scripts/build-schemas.ts` are **NOT** in the packed list (2012 files
packed; 216 `dist/`, 201 `src/`, 38 `liveness/`).
- ⚠️ A first negative control **failed and is reported rather than
swapped out quietly**: a source-only `//` comment in `page.zod.ts` reads
18 hits in `dist/`. `packages/spec`'s tsup build does **not** strip
comments, so for this package a comment inside a shipped module is
published text. Useful, and worth knowing before writing one.
- `packages/metadata-protocol` correctly carries **no** changeset entry:
its `files` is `["dist","README.md","CHANGELOG.md"]`, the corrected
sentence is an inline body comment, and it reads **0** files in that
package's `dist` (lit control `CLOSURE_CONTEXT_KEY_BY_TYPE` = 2, so the
instrument fired).

---

## ⚠️ One ruled item the tree refuses as written: the `major` grade

Ruling item 4 fixes the changeset at `major`. The changeset in this PR
**is** `major`, as ruled. A standing repo-wide gate refuses it:

```
$ node scripts/check-changeset-no-major.mjs --base origin/main        EXIT=1
⛔ This PR introduces changeset(s) that declare a `major` bump.
   .changeset/16929-page-assigned-profiles-removed.md
     - @objectstack/spec: major
Every publishable package is in the Changesets `fixed` (lockstep) group, so a single
`major` promotes the ENTIRE monorepo to a new major version. During the launch window
ship breaking changes as `minor` instead …
If a whole-stack major release is genuinely intended, add the `allow-major` label to
the PR to skip this check.
```

So the ruling **can** be executed literally — the gate names its own
escape — but the escape asserts *"a whole-stack major release is
genuinely intended"*, and that is a release-shaping claim well beyond
this card:

- every publishable package moves (~70, in lockstep) from `17.x` to
`18.0.0`;
- `scripts/sync-protocol-version.mjs` keys `PROTOCOL_VERSION` off the
spec package major, so the bump also flips the handshake to `18` and
activates the **24 other** `toMajor: 18` conversions already waiting in
the registry.

⛔ I have therefore **not** applied `allow-major`, and I have **not**
silently regraded the changeset to `minor`. The grade stands as ruled
and the gate stands red, with its reading recorded here. The remedy is
one line and it belongs to the seat or the maintainer:

- **keep `major`** ⇒ add the `allow-major` label, and this PR is the one
that cuts `18.0.0`; or
- **regrade to `minor`** ⇒ the launch-window convention, which the
gate's own prose says is carried instead by the **BREAKING** banner and
the ADR-0087 disposition — both of which this changeset already has. It
is also what the sibling `view-page-mount` retirement (an
identical-shape breaking removal, two days ago) did.

---

## The hot registry file — what was taken, and against which tip

`packages/spec/src/migrations/registry.ts` is contended by objectstack-ai#17792,
objectstack-ai#17638 and objectstack-ai#17635. **No entry number was taken, because there are
none.** The contended regions of that file are *generated*:
`src/migrations/entries/` holds one file per entry, filename derived
from the id, no index, concatenated by `gen:migration-registry` and
sorted by id (`entries/README.md` is the authority). This PR adds two
such files and **never edits between the markers**:

- `entries/retired-keys/18.ui__Page__assignedProfiles.ts` →
`RETIRED_KEYS_BY_MAJOR[18]`
-
`entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts`
→ `MIGRATIONS_BY_MAJOR[18].semantic`

Two hand-edited lines remain, both appends at the tail and neither
renumbering anything: `step18.conversionIds` gains
`'page-assigned-profiles-removed'` after `'view-page-mount-removed'`,
and `step18.rationale` gains a paragraph. ⛔ No other PR's entry is
renumbered or reordered.

**The merge, and what it actually collided with.** `origin/main` was
merged immediately before opening this PR: merge commit `8774a8c115`,
parents `3a1be112ff` (this branch) + **`c1078a5591`** (`origin/main` at
that moment). The collision was real but it was **not** a number — it
was `step18.rationale`, where objectstack-ai#17260's landed `object-kanban.quickAdd`
retirement and this card had each appended a paragraph to the same prose
field. Resolved semantically, **both intents kept**, main's paragraph
first:

- neither paragraph was dropped, shortened or reordered;
- the merge also silently dropped main's `"ui/ObjectKanbanProps:quickAdd
[RETIRED]"` baseline marker — the os-regen driver's documented
exit-0-while-dropping-a-side behaviour. `scripts/pm/os-regen-merge.sh`'s
order was followed (merge committed first, regeneration as its own
commit, `pre-commit` deferral discharged), and the regeneration put that
marker back. That restoration is visible as its own commit;
- ⛔ nothing belonging to objectstack-ai#17792, objectstack-ai#17638 or objectstack-ai#17635 was renumbered,
reordered or touched.

Everything in *Verification* below was re-run **on the merged tree**, at
`626ca34833`.

---

## ⭐ T1 of objectstack-ai#17618 — known false positive, recorded in advance

This PR declares `Clause-②: no`, which is the only condition under which
objectstack-ai#17618's T1 leg fires, and T1 reads a **re-declared** key line as a new
key (three false positives to date, most recently objectstack-ai#17796 on a
`.describe()` change). This diff re-declares `assignedProfiles` on its
existing key line and rewrites alias/guidance entries on existing lines,
so it is squarely in T1's blast radius. If T1 reds on a line this PR did
not add, **that is the false positive** — the reading goes here, and ⛔
`no` is not flipped to `yes` to clear a gate: the declared value is the
criterion, not the diff shape.

---

## Verification

Exit codes captured before any pipe. Heavy runs went through
`scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-16929`;
the verdict quoted is the wrapper's own `VERDICT` line, or a per-part
`echo "$?"` marker where parts were sequenced.

All of the following at **`626ca34833`**, the merged head.

| run | result |
| --- | --- |
| `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` |
| `pnpm --filter @objectstack/spec check:generated` | **all 15 artifacts
up to date** on the merged tree (an earlier pre-merge run found 2 stale
— `content/docs/references/**` and `liveness/state-counts.md` —
regenerated with `--fix`) |
| spec `test` + `typecheck` + platform-objects `test` +
metadata-protocol `typecheck`, joined with `&&` so one verdict covers
all four | `VERDICT command-exit 0` — spec: Test Files 471 passed (471),
Tests **13375 passed (13375)**; platform-objects: 39 files, 561 tests |
| `npx eslint . --no-inline-config --format json` — the **full repo
union, no narrowing claimed** | `ESLINT_EXIT=0` — **6647** files
received per `--format json`, **0 errors, 0 warnings** |
| `node scripts/pm/dispatch-gates.mjs --ran … --repo
objectstack-ai/objectstack` | `EXIT=0` — **114 derived, 112 run, 2
NOT-MEASURED, 0 UNRUN** |
| `node scripts/check-i18n-bundles.mjs --write` | first run `EXIT=3` =
**PREREQUISITE NOT MET = NOT MEASURED** (the built CLI closure was
absent); the closure was built (`VERDICT command-exit 0`, 57 tasks) and
the re-run is `EXIT=0`, all nine bundles regenerated |
| `pnpm check:nul-bytes` | `EXIT=0` — 8463 files scanned, no raw control
bytes. Own sweep over the 20 changed paths with `grep -naP` finds none;
lit control on an injected byte fires |
| `node scripts/check-changeset-no-major.mjs --base origin/main` |
`EXIT=1` — **deliberate, see above** |

The two NOT-MEASURED families both exited **3 = PREREQUISITE NOT MET**,
which is neither a pass nor a finding, and neither is counted green
here:

- `pnpm check:dual-build-cjs-loads` — reads built output for ten
packages this worktree never built (`@objectstack/studio`,
`client-react`, four connectors, …);
- `pnpm check:type-check-debt` — wants `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` first, and its own
text says ⛔ no ledger number may be raised on a run that measured
nothing.

CI's **Build Core** supplies both. Two families that first read red on a
stale build were re-run after rebuilding and are green:
`check:skill-examples` (`exit 0`, 258 prose examples across 3 surfaces —
it had refused on an unbuilt `packages/client-react/dist`, which was
then built) and `check:react-declaration-parity`, run **as CI runs it**
with `MANIFEST="$PWD/sdui.manifest.json" … --baseline
react-declaration-parity.baseline.json --strict` (`exit 0`, *"no new
DECLARATION divergence vs accepted baseline"*) — its own refusal text
says a complete local run is available from the checked-in root manifest
and ⛔ must not be reported as NOT MEASURED.

One earlier reading is withdrawn rather than quietly dropped: `pnpm
check:query-options-erasure` first read `exit 2`. That run happened
while `src/migrations/registry.ts` briefly held a merge-resolution
syntax error, and the gate parses that very file. Re-run on the fixed
tree it is `exit 0` — *"ratchet holds: 67 unswept non-test site(s) in 17
file(s), none new"*.

Regenerated artifacts, each reviewed rather than waved through:

- `authorable-surface/ui.json` — `"ui/Page:assignedProfiles"` →
`"ui/Page:assignedProfiles [RETIRED]"`
- `liveness/state-counts.md` — `page` 23 live → 22 live + 1 dead (total
24 unchanged); repo total 850→849 live, 93→94 dead
- `content/docs/references/ui/page.mdx` — the row's type becomes `never`
and carries the `[REMOVED]` prescription
- the four `*.metadata-forms.generated.ts` bundles lose the
`assignedProfiles` block (zh-CN 「指定配置文件 / 此页面对哪些 Profile 可用」,
ja-JP「割り当てプロファイル」, es-ES "Perfiles asignados", en)

Commit messages were swept for a card relation and a model identifier,
each stem counted separately: `fix` / `fixes` / `fixed` / `close` /
`closes` / `closed` / `resolve` / `resolves` / `resolved` / `part of` /
`refs` and `#`+digits all read **0**; `Opus` / `Sonnet` / `Haiku` read
0. Lit control `Claude` reads 8, so the sweep reaches. The only
`claude-` / `anthropic` hits are the mandated trailer pair.

## 验收备注

- `docs/adr/0010-nl-to-flow-authoring.md` mentions `assignedProfiles` in
a 2026-era open question. Untouched on purpose: `docs/adr/**` is a
governed surface, and one path hit would make this whole diff governed
and unmergeable by the queue. Noted, not filed.
- `docs/audits/2026-06-pageschema-property-liveness.md` and
`docs/audits/2026-06-security-identity-property-liveness.md` both name
the key. Untouched: they are dated audit records, and editing them would
falsify the record rather than correct it.
- `.changeset/page-guidance-stops-prescribing-assignedprofiles.md` (PR
objectstack-ai#17401's, still pending) states *"`assignedProfiles` remains an
authorable key with its declaration untouched"*. Accurate about that PR;
superseded by this one inside the same unreleased window. This changeset
says so rather than editing another PR's.

Authored by the `domain:spec` execution seat's `os-dev` round, session
`session_01MkQhmuuJAVDjmeWNixwDDH`, on branch
`claude/issue-16929-assignedprofiles-removal` at `626ca34833`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…code, and pins the re-declared key (objectstack-ai#18539)

Fixes objectstack-ai#17848

Clause-②: no

`REFUSAL_SENTENCE` in `scripts/pm/check-widening-tells.mjs` now names
the two doors that
actually move its exit code, and `--self-test` pins the re-declared-key
shape in both
directions. One file changed. ⛔ No exit code moves.

## First act: the card's own specimens, re-measured — half one did not
reproduce

The card's table was taken 2026-09-12T11:5xZ. Re-run today against all
three PR diffs it
names (fetched as the PR's own diff and fed to `--declaration no
--diff`), this matcher
reports **no widening tell on any of them**:

| specimen | judged files | tells | exit |
|:--|--:|--:|--:|
| PR objectstack-ai#17638 — `system/cache.zod.ts:197` | 3 of 7 (4 NOT MEASURED) | 0 |
0 |
| PR objectstack-ai#17796 — `ui/view.zod.ts:1615` | 1 of 3 (2 NOT MEASURED) | 0 | 0 |
| PR objectstack-ai#17846 — seven `filter:` doors | 3 of 5 (2 NOT MEASURED) | 0 | 0 |

⚠️ Every zero above is bracketed by controls, because a zero from a dead
invocation reads
the same as a zero from a decline:

- **Lit control** — a genuinely new key added to
`packages/spec/src/ui/view.zod.ts` (the
same file as objectstack-ai#17796's specimen) fires `T1 … :101` and exits **4**. The
surface covers
  these files and the run is alive.
- **Dark control** — the card's sharpest specimen reduced to its hunk,
with the removed
  line deleted and the three added lines byte-identical, fires
`T1 packages/spec/src/ui/component.zod.ts:2504` and exits **4**. The
silence is bought
  by the replacement, never by the shape.

⭐ And it did not fire at the card's own filing commit either. Running
`check-widening-tells.mjs` **as it stood at `758ac409`** (`origin/main`,
2026-09-12T11:38Z —
seventeen minutes before the card was written) against the same five
inputs reproduces the
same five readings: three specimens exit 0, both controls exit 4. The
repair had landed
three days earlier, in objectstack-ai#16943's per-change-block replacement budget: a
key re-declared in
place removes a T1 line and adds one, and the removal pays.

⇒ the card's "nine tells" was carried over from the threads that
accumulated on closed card
objectstack-ai#17618; it was not a reading of the gate on the day it was filed. Two of
the three PRs have
since merged (objectstack-ai#17846 on 2026-09-12, objectstack-ai#17638 on 2026-09-13) and objectstack-ai#17796 was
closed unmerged, so
the "three PRs parked" cost is also spent.

## Why no matcher change was made for half one

⛔ The shapes the card floated — pairing across a HUNK, or diffing the
file's key SET instead
of the block's added lines — are the silence `changeBlocks`'s own
docblock refuses: a hunk
carries three context lines each side and routinely holds an unrelated
removal at one end and
a real addition at the other, so pairing across it pays for a new key
with a removal that has
nothing to do with it. Buying that would trade a loud failure for a
quiet one to repair a
defect that is not there. **A fix that silences T1 generally is worse
than the bug**, and
this PR does not make one.

What half one did leave is a gap in the **instrument**, not in the
reader: the
`{ error: … }` re-declaration had no case of its own, and it is
arithmetically distinct from
the `.describe()` pair objectstack-ai#16943 pinned — the block removes ONE line and
adds THREE, of which
exactly one is a key. A budget counting LINES instead of KINDS comes up
short right there.
It is pinned now, with both controls above and a third:

- ⭐ **surplus control** — a genuinely new key (`filterLogic:`) added in
the SAME block as the
re-declaration still fires at its own file:line. One removal pays for
one key; a real
  widening riding along with a re-declaration is still caught.

## Half two — the remedy with no reader — is what this PR repairs

The sentence offered two doors and only one was real:

> re-declare `yes` or explain in the claim why this addition does not
widen

`c5WideningTell()` compares the declaration against the diff's tells and
stops there; nothing
in either file reads an explanation. So an author who followed the
instruction got the
identical exit 4 with no way to learn that the remedy was never
implemented — and the only
door that DID move the number was `Clause-②: no` → `yes`, which on a
false tell is the one
thing the standing rule forbids outright: 「⛔ 永不把 `no` 翻成 `yes` 去过门」. ⚠️
A gate
whose only working door is a lie teaches the lie.

⚠️ This file's header had already recorded that twice — at objectstack-ai#16822 and
again at objectstack-ai#16943 —
without ever changing the string a refused author actually reads. So the
file knew and the
author could not: a declared-but-unenforced remedy, which this repo
removes rather than
documents.

**Three shapes were weighed:**

1. **Delete the second branch.** ⛔ Refused. It leaves `re-declare yes`
as the only door,
which on a FALSE tell is exactly the forbidden lie — the message would
then instruct it.
2. **Give the explanation a reader.** ⛔ Refused. An author-written
sentence that clears the
author's own gate is 自查放行, and it needs the new claim-line syntax objectstack-ai#16448
forbids.
3. **Name the door that was always the right one.** ⭐ Chosen. objectstack-ai#16822
already ruled where a
demonstrated false positive gets repaired — HERE, in the matcher, with a
`--self-test`
case pinning the shape — and the sentence now says so, names the file to
open, allows
filing it as its own card when it is out of the PR's scope, and states
outright that an
explanation moves no exit code so nobody spends a round rediscovering
it.

Both doors the sentence now names are doors this file can open. That is
the pin.

## Exit-code contract — ⛔ UNCHANGED

`EXIT_OK` 0 · `EXIT_USAGE` 1 · `EXIT_INCOMPLETE` 2 · `EXIT_REFUSED` 4,
all unmoved, and no
verdict state changes. Every tell fires exactly where it fired: the only
behavioural surface
touched is the TEXT a refusal renders. The seat reading `$?` reads the
same table it read
yesterday.

## Verification

`--self-test`: **309 cases pass**, exit 0 — measured against **298** on
`origin/main`'s own
copy of this file, so 11 cases were added and none removed. Sibling
`check-clause2-carriers.mjs --self-test`: **715 cases pass**, exit 0 —
it renders
`REFUSAL_SENTENCE` unparaphrased and that pin still holds through the
constant.

All **31** commands derived by `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`
for this file surface were run; **every one exited 0**, each exit code
captured by
redirect-then-`$?`, never through a pipe.

**Non-vacuity — three ablation legs**, each proving the mutation reached
disk by a
`grep -c` (LINES) count on the anchored text before and after, each
restored under a
`trap … EXIT INT TERM`, and each restore proven by `git hash-object`
equalling the HEAD blob
`9ecddb0c…` with `git diff HEAD` empty:

| leg | mutation | on-disk proof | self-test | which cases red |
|:--|:--|:--|--:|:--|
| budget | the replacement budget never pays | 1 → 0 | exit 1, 26 fail |
the specimen case, the surplus control, and objectstack-ai#16943's own live pairs |
| t1-silent | T1 declines unconditionally | 0 → 1 | exit 1, 43 fail | ⭐
the dark control AND the surplus control — the "silencing T1 generally"
direction |
| old-sentence | the pre-objectstack-ai#17848 wording restored | 0 → 1 | exit 1, 4
fail | all four sentence pins |

⛔ The first attempt at the `t1-silent` leg counted an anchor the
mutation does not move
(before=1, after=1). It was reported as a void reading and re-run
against the injected text
rather than quietly retried until something landed.

**`skip-changeset`, measured not asserted.** `npm pack --dry-run --json
--ignore-scripts` in
`packages/spec` packs **271** entries (the lit control) and **0**
matching `scripts/pm` or
`check-widening-tells`. The root package is `private: true`, and 0 of
the 70 publishable
workspace packages contain the changed path. Nothing published moves.

## Acceptance notes

- **noted, not filed** — the card's own "nine tells, 2026-09-12T11:5xZ"
figure was not a
reading of the gate on that date; its table's `git grep -oF` counts
(which prove the keys
are not new) were fresh, the tell count was not. Successor: this PR's
body and the report.
- **noted, not filed** — `SELF_TEST_BATTERY_FLOOR` is 16 against a
roster of 22 declared
batteries, so five could be deleted without reddening. That is what a
floor IS (AGENTS.md
prescribes a minimum, not an equality), and adding batteries must not
red — an observation
  about slack, not a defect. Successor: none.
- ⛔ Nothing here attaches, removes or waits on `needs:contract-review`;
`scripts/pm/**` is
not in `GOVERNED_SURFACES` (`check-governed-merges.mjs`: `docs/adr/**`,
`.claude/**`,
`skills/**`, `AGENTS.md`, `CLAUDE.md`), so this is an ordinary landing
path.
- ⛔ The three PRs the card names were not touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_

---

## Landing note (seat, 2026-09-17)

Contract review at `CONTRACT_REVIEW_TIER` on head `f8ca2fdddf`: **PASS**
— record is comment `5706908992` on this PR.

⭐ The review did not reason about this gate, it **executed** it: it `git
archive`d a runnable subset at the head, at `origin/main` and at
`758ac409` (main seventeen minutes before the card was filed), ran
`--self-test` at each (309 / 298 / 269, all exit 0), re-fetched the
three specimen PR heads and fed their real diffs through all three file
versions, and ran three ablations with the blob hash re-verified after
each restore. That is what makes its central finding trustworthy.

**Central finding, which confirms this PR rather than undermining it:**
the card's half one — T1 firing on a re-declared key — **was never real
on any reachable version of the file**, with a lit control alive at
every one. The card's 「nine tells」 figure reproduces nowhere. Half two
(a remedy with no reader) **was** real and is what this PR fixes. ⇒
closing objectstack-ai#17848 on this PR is supportable.

**The direction that mattered — does this weaken the gate?** No. Every
predicate (`patchLines`, `changeBlocks`, `memberTellKind`, `tellsInFile`
incl. the objectstack-ai#16943 budget, `wideningRefusal`, `exitForRefusal`) is
byte-for-byte unchanged; the exported symbol list is identical at 46;
the exit register stays 0/1/2/4; and the t1-silent ablation reds both
the new dark and surplus controls. The only output change is the text of
`REFUSAL_SENTENCE`, which no parser outside the file reads (0 hits,
control 9).

⚠️ **Two self-narration discrepancies in this body, appended rather than
rewritten** (this repo squashes, so the body becomes the permanent
commit message): the battery roster is **21** at head, not the 22 the
body states (20 at main); and the budget-ablation failure count measured
**24**, not 26 — the direction is confirmed, the exact figure is not as
stated.

⚠️ **One pin label overclaims, recorded not fixed:** 「the row it reports
is the new key, never the re-declared one」 holds for the fixture's
ordering only. With the new key written *before* the re-declaration in
the same block the gate still refuses (1 tell) but reports the
re-declared line. That is objectstack-ai#16943's pre-existing patch-order budget and
it is the loud direction, so nothing is weakened — the word 「never」 is
simply too strong. ⛔ Not fixed in-branch: that moves the head and voids
an otherwise complete review record, for a word in a test label.

**Pre-landing checks:** ① review PASS on record ✅ · ②
`check-clause2-carriers --pair 18539` exit 0; ⛔ no carriers were hung on
this pair (`Clause-②: no`, no declared surface) so there is nothing to
strip ✅ · ③ re-taken at landing time, latest-run-per-check-name ✅.
Governed-surface predicate: **0 of 1 path hits the register** ⇒ ordinary
queue landing.

---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants